fix(security): bind the extension control channel to loopback and gate its handshake - #53
Merged
Merged
Conversation
…e its handshake
The WebSocket server was created as `new WebSocket.Server({ port: WS_PORT })`.
With no `host`, Node listens on all interfaces, so the browser-automation
control channel was reachable from the LAN. The connection handler hands
`chromeExtensionSocket` to whoever connects last, closing the previous socket,
so any reachable peer could evict the real extension, receive every automation
command the MCP server issues, and return fabricated results to the model.
Two changes:
- Bind to 127.0.0.1. The extension connects to ws://localhost:5555 from the
same machine and the optional ngrok tunnel forwards the HTTP port only, so
nothing legitimate needs this off-host.
- Add a verifyClient gate. Loopback alone is not sufficient: a web page the
user visits can also reach 127.0.0.1. Browsers send Origin on a cross-origin
WebSocket handshake, so page-initiated connections are rejected while
extension-scheme origins and local non-browser clients are allowed.
Verified against a running server:
bind address 127.0.0.1:5711 (was *:5711)
loopback + chrome-extension:// ACCEPTED
loopback + no origin ACCEPTED
loopback + https://evil.example REJECTED 403
LAN 10.x.x.x ECONNREFUSED
Not addressed here: the HTTP/SSE server still listens on all interfaces with
`Access-Control-Allow-Origin: *`. That one needs a separate decision because
remote MCP clients and the tunnel path may depend on it.
Co-Authored-By: Claude <noreply@anthropic.com>
This was referenced Jul 20, 2026
aaronjmars
added a commit
that referenced
this pull request
Jul 20, 2026
… remote access (#55) Closes #54. `POST /sse` hands its body straight to handleMCPRequest, which drives the browser through the extension, and it was reachable two ways it should not have been: the Express app listened on all interfaces, and CORS answered every preflight with `Access-Control-Allow-Origin: *`. So any LAN peer could call it, and any website the user visited could call it cross-origin and read the reply. #53 closed the equivalent hole on the WebSocket channel; this is the other half. Three changes, mirroring what #53 established: - Bind the HTTP server to 127.0.0.1. `ngrok http` runs on this machine and forwards to localhost, and the extension fetches /ports from localhost, so nothing legitimate needed the wider bind. --http-host= widens it deliberately. - Gate every request on Origin. Browsers send Origin cross-origin, so page requests (http(s), and "null" from a sandbox) are refused 403 while extension service workers and non-browser MCP clients pass. CORS now reflects only allowed origins instead of `*`, so a page cannot read a response either. - Require a bearer token once the surface leaves this machine — --tunnel or a non-loopback --http-host. Generated and printed at startup, or pinned with --token=. The tunnel was previously unauthenticated to anyone who had the URL. Verified against a running server. Default (local) mode: bind address 127.0.0.1:5556 (was *:5556) LAN POST /sse ECONNREFUSED loopback, no Origin 200 loopback, Origin https://evil 403 loopback, simple req, no preflight 403 loopback, Origin null 403 GET /ports, chrome-extension:// 200 preflight from evil page 403, no ACAO header preflight from extension 204, ACAO reflects the extension With --http-host=0.0.0.0: LAN POST /sse, no token 401 LAN POST /sse, wrong token 401 LAN POST /sse, token prefix 401 LAN POST /sse, correct token 200 LAN POST /sse, token + evil Origin 403 The WebSocket channel stays on loopback regardless of --http-host. Co-authored-by: Claude <noreply@anthropic.com>
aaronjmars
added a commit
that referenced
this pull request
Jul 20, 2026
…changes (#57) * docs: add a changelog and a release checklist that surfaces breaking changes #55 made `--tunnel` require a bearer token on /sse. That is a real break for anyone running a tunnelled connector today — it will present as "my ChatGPT connector stopped working" the moment they upgrade — and right now nothing in the repo would remind us to say so at tag time. Release notes are hand-written per release, and `.github/workflows/publish.yml` pushes to npm on a `v*` tag, so the tag is the point of no return. Add `.github/CHANGELOG.md`, seeded with an Unreleased section covering everything landed since v1.1.1 (13 commits): the tunnel token under⚠️ Breaking with the migration line spelled out, the three security fixes (#53, #55, #56), the node-forge/brace-expansion bump (#43), and the dependency and docs maintenance. Add a Releasing section to CONTRIBUTING that makes reading Unreleased step 1 of cutting a tag, and states that a⚠️ Breaking entry means a major bump with the migration step at the top of the release notes. Also ask PR authors to add their own entry, so the file stays accurate instead of being reconstructed later. No code changes. Co-Authored-By: Claude <noreply@anthropic.com> * docs: link the adm-zip override to its removal issue (#58) --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(security): bind the extension control channel to loopback and gate its handshake
The WebSocket server was created as
new WebSocket.Server({ port: WS_PORT }).With no
host, Node listens on all interfaces, so the browser-automationcontrol channel was reachable from the LAN. The connection handler hands
chromeExtensionSocketto whoever connects last, closing the previous socket,so any reachable peer could evict the real extension, receive every automation
command the MCP server issues, and return fabricated results to the model.
Two changes:
same machine and the optional ngrok tunnel forwards the HTTP port only, so
nothing legitimate needs this off-host.
user visits can also reach 127.0.0.1. Browsers send Origin on a cross-origin
WebSocket handshake, so page-initiated connections are rejected while
extension-scheme origins and local non-browser clients are allowed.
Verified against a running server:
bind address 127.0.0.1:5711 (was *:5711)
loopback + chrome-extension:// ACCEPTED
loopback + no origin ACCEPTED
loopback + https://evil.example REJECTED 403
LAN 10.x.x.x ECONNREFUSED
Not addressed here: the HTTP/SSE server still listens on all interfaces with
Access-Control-Allow-Origin: *. That one needs a separate decision becauseremote MCP clients and the tunnel path may depend on it.
Co-Authored-By: Claude noreply@anthropic.com